Skip to content

feat: implement MCP elicitation support (#413) #548

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from

Conversation

JBUinfo
Copy link

@JBUinfo JBUinfo commented Aug 14, 2025

Exactly this PR #495 but fixing conflicts + HTTP support

Fixes #413

Summary by CodeRabbit

  • New Features

    • First-class elicitation between clients and servers: interactive requests with Accept/Decline/Cancel, advertised during initialization and supported across transports (stdio, in‑process, streamable HTTP, in‑process transport, client transport).
  • Documentation

    • New example demonstrating project creation and multi-step confirmations, conditional follow-ups, and usage patterns.
  • Tests

    • Comprehensive client, server, in‑process and transport tests covering accept/decline/cancel flows, error paths, initialization, and integration.

Copy link
Contributor

coderabbitai bot commented Aug 14, 2025

Walkthrough

Adds end-to-end elicitation: new MCP method and types, client ElicitationHandler and option, server RequestElicitation and ServerOption, transport/session support (stdio, streamable HTTP, in-process), tests, and an example demonstrating accept/decline/cancel and schema-bearing requests.

Changes

Cohort / File(s) Summary
MCP protocol types
mcp/types.go
Adds MethodElicitationCreate, client/server Elicitation capability flags, and types: ElicitationRequest, ElicitationParams, ElicitationResult, ElicitationResponse, ElicitationResponseType and constants (`accept
Client core & API
client/client.go, client/elicitation.go
Adds ElicitationHandler interface, elicitationHandler field on Client, WithElicitationHandler ClientOption, advertises elicitation capability during Initialize, and adds transport-level handlers for elicitation/create (plus ping handling).
Client tests & in-process integration
client/elicitation_test.go, client/inprocess_elicitation_test.go
Adds unit and in-process tests covering no-handler error, accept/decline/cancel flows, option wiring, Initialize flow, in-process adapter and helper.
Client transport (in-process)
client/transport/inprocess.go
Adds elicitationHandler field and WithElicitationHandler option; creates sessions via NewInProcessSessionWithHandlers when sampling or elicitation handlers are present.
Client transport (streamable HTTP)
client/transport/streamable_http.go
Formatting/whitespace and deadline/connect changes (removes fixed time-based deadlines; uses cancellable contexts).
Server API & options
server/server.go, server/elicitation.go
Adds WithElicitation() ServerOption, internal capability flag, and MCPServer.RequestElicitation delegating to the active session.
Server session interfaces & in-process session
server/session.go, server/inprocess_session.go
Adds SessionWithElicitation interface, extends InProcessSession with elicitationHandler, adds NewInProcessSessionWithHandlers, and RequestElicitation on the session.
Server stdio session
server/stdio.go
Implements RequestElicitation on stdioSession, pending-elicitation tracking, elicitation response routing/handling, and asserts stdioSession implements SessionWithElicitation.
Server streamable HTTP transport
server/streamable_http.go
Unifies response handling, introduces per-request responseItem and unified requests map, adds SSE path to send elicitation/create, and RequestElicitation on streamable session with elicitation channels.
Server tests
server/elicitation_test.go, server/streamable_http_sampling_test.go
Adds server-side tests for RequestElicitation (no-session, unsupported-session, accept/decline flows) and adjusts sampling tests to new response channel types.
Examples
examples/elicitation/main.go
New stdio example demonstrating elicitation flows (project creation, confirmation) with schema, Accept/Decline/Cancel handling and textual tool outputs.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Assessment against linked issues

Objective Addressed Explanation
Add ElicitRequest and ElicitResult types (#413)
Implement elicitation capability in server (#413)
Add client-side elicitation handling (#413)
Support for structured elicitation with request schemas (#413)

Out-of-scope changes

Code Change Explanation
Removal of time-based deadlines in SSE/connect handling (client/transport/streamable_http.go) Changes transport timeout/connect behavior unrelated to elicitation feature; not required by the linked issue objectives.

Possibly related PRs

Suggested labels

type: enhancement, area: mcp spec

Suggested reviewers

  • pottekkat
  • ezynda3
  • rwjblue-glean

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🔭 Outside diff range comments (6)
client/transport/inprocess.go (4)

57-66: Ensure sessionID is generated before creating/registering the session

If NewInProcessTransport() (without options) is used, sessionID remains empty. Creating a session with an empty ID can break RegisterSession/UnregisterSession lookups and GetSessionId. Generate an ID when missing and persist it.

Apply this diff:

 func (c *InProcessTransport) Start(ctx context.Context) error {
-	// Create and register session if we have handlers
-	if c.samplingHandler != nil || c.elicitationHandler != nil {
-		c.session = server.NewInProcessSessionWithHandlers(c.sessionID, c.samplingHandler, c.elicitationHandler)
+	// Create and register session if we have handlers
+	if c.samplingHandler != nil || c.elicitationHandler != nil {
+		// Ensure we have a session ID
+		if c.sessionID == "" {
+			c.sessionID = c.server.GenerateInProcessSessionID()
+		}
+		c.session = server.NewInProcessSessionWithHandlers(c.sessionID, c.samplingHandler, c.elicitationHandler)
 		if err := c.server.RegisterSession(ctx, c.session); err != nil {
 			return fmt.Errorf("failed to register session: %w", err)
 		}
 	}
 	return nil
 }

94-103: Preserve session context for notifications

Notifications should also carry the client session in the context to keep behavior consistent with requests (logging level, client info, etc.).

Apply this diff:

 func (c *InProcessTransport) SendNotification(ctx context.Context, notification mcp.JSONRPCNotification) error {
 	notificationBytes, err := json.Marshal(notification)
 	if err != nil {
 		return fmt.Errorf("failed to marshal notification: %w", err)
 	}
 	notificationBytes = append(notificationBytes, '\n')
+	// Add session to context if available
+	if c.session != nil {
+		ctx = c.server.WithContext(ctx, c.session)
+	}
 	c.server.HandleMessage(ctx, notificationBytes)

 	return nil
 }

111-116: Use actual session ID when unregistering

If sessionID wasn’t set on construction, UnregisterSession with an empty ID will fail. Fall back to the session’s ID.

Apply this diff:

 func (c *InProcessTransport) Close() error {
 	if c.session != nil {
-		c.server.UnregisterSession(context.Background(), c.sessionID)
+		sid := c.sessionID
+		if sid == "" {
+			sid = c.session.SessionID()
+		}
+		c.server.UnregisterSession(context.Background(), sid)
 	}
 	return nil
 }

118-120: Fix: Return actual session ID from GetSessionId()

Currently always returns an empty string.

Apply this diff:

 func (c *InProcessTransport) GetSessionId() string {
-	return ""
+	if c.sessionID != "" {
+		return c.sessionID
+	}
+	if c.session != nil {
+		return c.session.SessionID()
+	}
+	return ""
 }
server/stdio.go (2)

223-225: Same stdout write race in RequestSampling

Same issue as RequestElicitation: direct writes to the shared stdout without coordinating with the server’s write lock. Use the same writeWithGlobalLock helper to serialize all writes.

Apply this diff:

- if _, err := writer.Write(requestBytes); err != nil {
-   return nil, fmt.Errorf("failed to write sampling request: %w", err)
- }
+ if _, err := writeWithGlobalLock(writer, requestBytes); err != nil {
+   return nil, fmt.Errorf("failed to write sampling request: %w", err)
+ }

695-701: Unify server write path with the same global write lock to avoid interleaving with session-originated requests

writeResponse currently uses a different mutex (s.writeMu) than session-originated requests. To guarantee atomic frames across all writers, route all writes through the same global lock.

Apply this diff:

- // Protect concurrent writes
- s.writeMu.Lock()
- defer s.writeMu.Unlock()
-
- // Write response followed by newline
- if _, err := fmt.Fprintf(writer, "%s\n", responseBytes); err != nil {
-   return err
- }
+ // Write response followed by newline under shared lock
+ if _, err := writeWithGlobalLock(writer, append(responseBytes, '\n')); err != nil {
+   return err
+ }

Optionally, remove s.writeMu if no other code depends on it after this change.

🧹 Nitpick comments (17)
mcp/types.go (3)

59-62: Introduce elicitation method: looks correct and consistent with naming.

Constant name and value are consistent with the rest of the method enum. Non-blocking nit: consider aligning the comment URL to the spec path once a canonical spec page exists.


834-841: Prefer a more precise type for RequestedSchema to improve safety and interop.

Using any works, but you’ll lose type guarantees and may incur unnecessary reflection. Two better options:

  • Use json.RawMessage to preserve arbitrary JSON without reflection.
  • If the repo already standardizes on a JSON Schema type (e.g., the Tool InputSchema handling per our past learnings), reuse that type for consistency.

Suggested minimal change:

-    RequestedSchema any `json:"requestedSchema"`
+    RequestedSchema json.RawMessage `json:"requestedSchema"`

842-860: Response envelope is well modeled; consider adding a helper validator.

The tri-state response with optional Value is clear. Consider adding a small helper (outside this file) to validate that Value is present iff Type is “accept” and to enforce schema-validation errors vs user-decline/cancel semantics. This will make downstream handler code simpler and safer.

server/session.go (1)

55-61: SessionWithElicitation interface is clear; document cancel vs error semantics.

Consider clarifying in a comment when to return an ElicitationResult with Type=cancel vs returning an error (e.g., transport interruption vs user-initiated cancel). This avoids ambiguity for implementers across transports.

client/elicitation.go (1)

9-19: ElicitationHandler contract looks good; add guidance on cancel/error handling.

Interface is minimal and clear. Non-blocking: add a sentence that user-initiated cancellations should produce a result with Type=cancel, while transport or context cancellations should return an error. This aligns client behavior with server expectations.

server/elicitation.go (1)

10-25: Server-side elicitation request routing is sound

Correctly retrieves the client session from context and delegates only if SessionWithElicitation is implemented. Error messages match tests.

Consider defining exported sentinel errors for "no active session" and "session does not support elicitation" to enable callers to handle them programmatically without string matching in the future. Keep current messages for test compatibility.

client/client.go (1)

527-573: Transport-level elicitation handling is correct and mirrors sampling

  • Validates handler presence
  • Parses params safely via JSON round-trip
  • Constructs mcp.ElicitationRequest and returns properly marshaled result

Sampling and elicitation transport handlers share identical request-param parsing and response-marshaling patterns. Consider extracting a small generic helper to reduce duplication and error-prone maintenance (example skeleton):

// outside the changed ranges – illustrative
func (c *Client) handleBidi[TParams any, TResult any](
    ctx context.Context,
    req transport.JSONRPCRequest,
    build func(TParams) (any, error), // constructs the MCP request to call the handler with
    call func(context.Context, any) (TResult, error), // invokes the specific handler
) (*transport.JSONRPCResponse, error) {
    var params TParams
    if req.Params != nil {
        b, err := json.Marshal(req.Params)
        if err != nil {
            return nil, fmt.Errorf("failed to marshal params: %w", err)
        }
        if err := json.Unmarshal(b, &params); err != nil {
            return nil, fmt.Errorf("failed to unmarshal params: %w", err)
        }
    }
    mcpReq, err := build(params)
    if err != nil {
        return nil, err
    }
    result, err := call(ctx, mcpReq)
    if err != nil {
        return nil, err
    }
    rb, err := json.Marshal(result)
    if err != nil {
        return nil, fmt.Errorf("failed to marshal result: %w", err)
    }
    return &transport.JSONRPCResponse{
        JSONRPC: mcp.JSONRPC_VERSION,
        ID:      req.ID,
        Result:  json.RawMessage(rb),
    }, nil
}

This keeps both flows uniform and easier to extend (e.g., future bidirectional methods).

server/elicitation_test.go (1)

114-172: Happy-path test validates Accept flow and structured Value

Asserts type and content of response payload; representative example of structured elicitation.

You could add an additional sub-assert that Value conforms to the declared schema in the test (e.g., check both fields exist and are strings) to better document intended behavior.

server/stdio.go (3)

629-683: Tighten response discrimination to avoid false positives

If a message has an id but neither result nor error, this method will parse, attempt to unmarshal nil, and treat it as an elicitation response error. Align this with handleSamplingResponse by ignoring such frames.

Apply this diff:

- id, err := response.ID.Int64()
- if err != nil {
-   return false
- }
+ id, err := response.ID.Int64()
+ if err != nil || (response.Result == nil && response.Error == nil) {
+   return false
+ }

423-446: Potential goroutine leak on context cancel in readNextLine

When ctx is cancelled, this returns "", nil but leaves the spawned goroutine blocked on ReadString. This can leak goroutines until stdin closes. Consider refactoring to avoid a goroutine per read or to unblock the reader on cancel (e.g., by using an interruptible reader, piping through an io.Pipe on shutdown, or a single long-lived goroutine that you signal to stop).

I can draft a refactor using a single read goroutine feeding a channel that the loop selects on, with proper shutdown semantics.


327-333: Nit: comment contradicts behavior

Comment says “Default to discarding logs” but logs are directed to os.Stderr.

Apply this diff to clarify:

- ), // Default to discarding logs
+ ),

Or update the comment to match the behavior.

client/inprocess_elicitation_test.go (5)

23-33: Prefer map[string]any over map[string]interface{} for consistency

Throughout the repository, any is used. Keep tests consistent to avoid noise in diffs and improve readability.

Apply this diff:

-       Value: map[string]interface{}{
+       Value: map[string]any{
          "response": "User provided data",
          "accepted": true,
        },

39-53: Minor: use map[string]any consistently in schema definitions

The file mixes map[string]any and map[string]interface{}. Align on any.

Apply this diff:

-   Properties: map[string]any{
-     "action": map[string]any{
+   Properties: map[string]any{
+     "action": map[string]any{
        "type":        "string",
        "description": "Action to perform",
      },
    },

And within the elicitation request:

- RequestedSchema: map[string]interface{}{
+ RequestedSchema: map[string]any{
     "type": "object",
-    "properties": map[string]interface{}{
-      "confirm": map[string]interface{}{
+    "properties": map[string]any{
+      "confirm": map[string]any{
         "type":        "boolean",
         "description": "Confirm the action",
       },
-      "details": map[string]interface{}{
+      "details": map[string]any{
         "type":        "string",
         "description": "Additional details",
       },
     },

193-197: Prefer public option to set handler rather than mutating unexported field

Setting client.elicitationHandler directly couples the test to internal details. Use the functional option to exercise public API.

Apply this diff:

- client := NewClient(inProcessTransport)
- client.elicitationHandler = handler
+ client := NewClient(inProcessTransport, WithElicitationHandler(handler))

80-93: Coverage: add a failing elicitation path test

The tool path already returns a CallToolResult with IsError true when RequestElicitation fails. Add a test that injects a failing handler or forces RequestElicitation to error and assert the error content and IsError=true.

I can add a subtest that makes the handler return an error and asserts the tool returns a text error and IsError=true.


171-173: String stability

Tests compare exact strings. Consider asserting on the response type (Accept/Decline/Cancel) and presence of substrings to reduce brittleness to minor text changes.

I can adjust the assertion to check Contains("User accepted") instead of full equality if desired.

client/elicitation_test.go (1)

84-123: Strengthen assertions by checking ID echo and response payload

After unmarshalling, also assert that the response type matches the test case and that the Request/Response IDs are echoed back.

For example:

if result != nil {
  if result.ID != request.ID {
    t.Errorf("expected response ID %v, got %v", request.ID, result.ID)
  }
  var elicitationResult mcp.ElicitationResult
  if err := json.Unmarshal(result.Result, &elicitationResult); err != nil {
    t.Errorf("failed to unmarshal result: %v", err)
  } else if tt.expectedError == "" && elicitationResult.Response.Type == "" {
    t.Error("empty response type")
  }
}
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these settings in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 9f16336 and f149690.

📒 Files selected for processing (13)
  • client/client.go (5 hunks)
  • client/elicitation.go (1 hunks)
  • client/elicitation_test.go (1 hunks)
  • client/inprocess_elicitation_test.go (1 hunks)
  • client/transport/inprocess.go (3 hunks)
  • examples/elicitation/main.go (1 hunks)
  • mcp/types.go (4 hunks)
  • server/elicitation.go (1 hunks)
  • server/elicitation_test.go (1 hunks)
  • server/inprocess_session.go (4 hunks)
  • server/server.go (3 hunks)
  • server/session.go (1 hunks)
  • server/stdio.go (6 hunks)
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-03-04T06:59:43.882Z
Learnt from: xinwo
PR: mark3labs/mcp-go#35
File: mcp/tools.go:107-137
Timestamp: 2025-03-04T06:59:43.882Z
Learning: Tool responses from the MCP server shouldn't contain RawInputSchema, which is why the UnmarshalJSON method for the Tool struct is implemented to handle only the structured InputSchema format.

Applied to files:

  • mcp/types.go
📚 Learning: 2025-06-30T07:13:17.052Z
Learnt from: ezynda3
PR: mark3labs/mcp-go#461
File: server/sampling.go:22-26
Timestamp: 2025-06-30T07:13:17.052Z
Learning: In the mark3labs/mcp-go project, the MCPServer.capabilities field is a struct value (serverCapabilities), not a pointer, so it cannot be nil and doesn't require nil checking. Only pointer fields within the capabilities struct should be checked for nil.

Applied to files:

  • server/server.go
🧬 Code Graph Analysis (11)
client/elicitation.go (1)
mcp/types.go (2)
  • ElicitationRequest (829-832)
  • ElicitationResult (843-850)
server/session.go (1)
mcp/types.go (2)
  • ElicitationRequest (829-832)
  • ElicitationResult (843-850)
client/elicitation_test.go (5)
mcp/types.go (18)
  • ElicitationResult (843-850)
  • ElicitationRequest (829-832)
  • ElicitationResponse (853-859)
  • ElicitationResponseTypeAccept (866-866)
  • ElicitationResponseTypeDecline (868-868)
  • ElicitationResponseTypeCancel (870-870)
  • JSONRPCRequest (329-334)
  • NewRequestId (263-265)
  • MethodElicitationCreate (61-61)
  • Params (181-181)
  • Result (250-254)
  • InitializeResult (434-448)
  • Implementation (506-509)
  • ServerCapabilities (476-503)
  • JSONRPCNotification (337-340)
  • InitializeRequest (418-422)
  • InitializeParams (424-430)
  • ClientCapabilities (459-471)
client/elicitation.go (1)
  • ElicitationHandler (11-19)
server/inprocess_session.go (1)
  • ElicitationHandler (19-21)
client/client.go (3)
  • Client (17-29)
  • WithElicitationHandler (50-54)
  • NewClient (71-81)
client/transport/inprocess.go (1)
  • WithElicitationHandler (32-36)
server/elicitation.go (2)
mcp/types.go (2)
  • ElicitationRequest (829-832)
  • ElicitationResult (843-850)
server/session.go (2)
  • ClientSessionFromContext (82-87)
  • SessionWithElicitation (56-60)
server/inprocess_session.go (4)
mcp/types.go (3)
  • ElicitationRequest (829-832)
  • ElicitationResult (843-850)
  • JSONRPCNotification (337-340)
client/elicitation.go (1)
  • ElicitationHandler (11-19)
server/session.go (4)
  • ClientSession (11-20)
  • SessionWithLogging (23-29)
  • SessionWithClientInfo (43-53)
  • SessionWithElicitation (56-60)
server/sampling.go (1)
  • SessionWithSampling (42-45)
client/client.go (5)
client/elicitation.go (1)
  • ElicitationHandler (11-19)
server/inprocess_session.go (1)
  • ElicitationHandler (19-21)
client/transport/inprocess.go (1)
  • WithElicitationHandler (32-36)
mcp/types.go (10)
  • JSONRPCRequest (329-334)
  • JSONRPCResponse (343-347)
  • MethodSamplingCreateMessage (877-877)
  • MethodElicitationCreate (61-61)
  • ElicitationParams (835-840)
  • Params (181-181)
  • ElicitationRequest (829-832)
  • Request (172-175)
  • JSONRPC_VERSION (115-115)
  • Result (250-254)
client/transport/interface.go (2)
  • JSONRPCRequest (57-62)
  • JSONRPCResponse (64-73)
server/elicitation_test.go (4)
mcp/types.go (9)
  • JSONRPCNotification (337-340)
  • ElicitationResult (843-850)
  • ElicitationRequest (829-832)
  • Params (181-181)
  • ElicitationParams (835-840)
  • ElicitationResponse (853-859)
  • ElicitationResponseTypeAccept (866-866)
  • ElicitationResponseType (862-862)
  • ElicitationResponseTypeDecline (868-868)
server/server.go (2)
  • NewMCPServer (307-333)
  • WithElicitation (293-297)
mcp/utils.go (1)
  • ToBoolPtr (861-863)
server/session.go (1)
  • ClientSession (11-20)
server/server.go (1)
mcp/utils.go (1)
  • ToBoolPtr (861-863)
client/transport/inprocess.go (1)
server/inprocess_session.go (4)
  • SamplingHandler (14-16)
  • ElicitationHandler (19-21)
  • InProcessSession (23-33)
  • NewInProcessSessionWithHandlers (43-50)
client/inprocess_elicitation_test.go (7)
mcp/types.go (13)
  • ElicitationRequest (829-832)
  • ElicitationResult (843-850)
  • ElicitationResponse (853-859)
  • ElicitationResponseTypeAccept (866-866)
  • Params (181-181)
  • ElicitationParams (835-840)
  • Content (941-943)
  • TextContent (947-954)
  • TextContent (956-956)
  • ElicitationResponseTypeDecline (868-868)
  • ElicitationResponseTypeCancel (870-870)
  • LATEST_PROTOCOL_VERSION (105-105)
  • ClientCapabilities (459-471)
server/server.go (2)
  • WithElicitation (293-297)
  • MCPServer (144-169)
mcp/tools.go (5)
  • Tool (557-572)
  • Description (851-855)
  • ToolInputSchema (612-617)
  • Properties (1093-1097)
  • Required (859-863)
client/elicitation.go (1)
  • ElicitationHandler (11-19)
server/inprocess_session.go (1)
  • ElicitationHandler (19-21)
client/client.go (3)
  • Client (17-29)
  • WithElicitationHandler (50-54)
  • NewClient (71-81)
client/transport/inprocess.go (2)
  • NewInProcessTransportWithOptions (44-55)
  • WithElicitationHandler (32-36)
server/stdio.go (3)
mcp/types.go (8)
  • JSONRPCNotification (337-340)
  • ElicitationResult (843-850)
  • ElicitationRequest (829-832)
  • Params (181-181)
  • ElicitationParams (835-840)
  • JSONRPC_VERSION (115-115)
  • MethodElicitationCreate (61-61)
  • Result (250-254)
server/session.go (2)
  • ClientSession (11-20)
  • SessionWithElicitation (56-60)
server/sampling.go (1)
  • SessionWithSampling (42-45)
🔇 Additional comments (27)
mcp/types.go (4)

469-471: Client capability flag added appropriately.

Using a pointer to an empty struct mirrors existing capability flags (e.g., Sampling) and keeps the wire format minimal. No issues.


501-503: Server capability flag added appropriately.

Matches the client-side shape and is correctly nullable for opt-in behavior in initialize.


825-832: ElicitationRequest wrapper shape is fine and consistent with other request types.

Named Params field is consistent with many existing request wrappers in this file (e.g., ReadResourceRequest). Good placement and JSON tags.


864-871: Enum values are reasonable and easy to consume.

No issues with the response type constants.

server/server.go (3)

180-186: Capability plumbing extended with elicitation (opt-in).

Adding elicitation *bool aligns with the existing pattern (e.g., logging, sampling). Looks good.


639-642: Initialize advertises elicitation correctly when enabled.

Consistent with other capabilities. No concerns.


292-298: Elicitation option: end-to-end implementation verified

WithElicitation is OK — the server method, session interface + implementations, method constant, and tests are present.

  • server/elicitation.go: func (s *MCPServer) RequestElicitation(...)
  • server/session.go: RequestElicitation declared on the Session interface
  • server/stdio.go, server/inprocess_session.go: concrete RequestElicitation implementations
  • mcp/types.go: MethodElicitationCreate = "elicitation/create"
  • Tests: server/elicitation_test.go, client/elicitation_test.go, client/inprocess_elicitation_test.go
examples/elicitation/main.go (1)

46-50: RequestElicitation usage looks good.

The example demonstrates the intended request/response flow and handles all response types. Once the safety checks above are added, this is a solid showcase.

client/transport/inprocess.go (2)

14-18: Addition of elicitation handler field looks correct

Wires in elicitation support alongside sampling in the in-process transport.


32-36: New option for elicitation handler is consistent with sampling

Public option mirrors existing WithSamplingHandler pattern. No issues.

client/client.go (4)

28-29: Client now tracks an elicitation handler

Field addition is minimal and consistent with sampling.


48-55: WithElicitationHandler option is correct and documented

Mirrors the sampling option with appropriate capability advertisement note.


179-183: Initialize advertises elicitation capability when handler is set

This aligns with capability negotiation and the new bidirectional request type.


467-474: Incoming request switch extended to handle elicitation

Approach is consistent with sampling; default branch remains defensive.

server/inprocess_session.go (5)

18-22: ElicitationHandler interface is well-scoped

Clear separation of concerns and symmetric with SamplingHandler.


31-33: Session now tracks elicitation handler

Read/write guarded by RWMutex in RequestElicitation; good thread-safety practice.


43-50: Constructor with both handlers improves clarity

NewInProcessSessionWithHandlers simplifies wiring from transports/tests.


119-129: RequestElicitation correctly delegates to handler

Uses read lock, nil-check, and returns handler output. Error message consistent with tests.


138-143: Interface compliance assertions are complete

Catches regressions at compile time.

server/elicitation_test.go (2)

84-112: Unsupported-session test reads cleanly and validates exact error

Uses WithElicitation option and a session without elicitation support. Solid coverage.


174-239: No action needed — fakeSession is defined in tests

The repository contains type fakeSession at server/server_test.go:1802 (found by rg). The original concern about an undefined type is incorrect — do not apply the suggested diff.

  • server/server_test.go:1802 — type fakeSession struct { ... }

Likely an incorrect or invalid review comment.

server/stdio.go (3)

302-307: LGTM: safe writer swap

Using defer to unlock improves exception-safety during writer updates.


317-321: LGTM: initialized pending maps for sampling and elicitation

Prevents nil map writes at runtime.


520-524: Ordering is fine; responses are intercepted before server handling

Processing sampling and elicitation responses prior to routing to the server avoids misclassification and unnecessary work.

client/inprocess_elicitation_test.go (1)

115-123: Nice: end-to-end in-process setup validates elicitation plumbing

Creating a real MCP server with a tool that triggers elicitation exercises the path realistically.

client/elicitation_test.go (2)

26-78: Good breadth of cases

Table covers no-handler, accept/decline/cancel, and handler error propagation. Nicely scoped.


192-226: Mock transport shape is sufficient

Start/Close/no-op notification handler are adequate for these tests.

Comment on lines +239 to +300
// RequestElicitation sends an elicitation request to the client and waits for the response.
func (s *stdioSession) RequestElicitation(ctx context.Context, request mcp.ElicitationRequest) (*mcp.ElicitationResult, error) {
s.mu.RLock()
writer := s.writer
s.mu.RUnlock()

if writer == nil {
return nil, fmt.Errorf("no writer available for sending requests")
}

// Generate a unique request ID
id := s.requestID.Add(1)

// Create a response channel for this request
responseChan := make(chan *elicitationResponse, 1)
s.pendingMu.Lock()
s.pendingElicitations[id] = responseChan
s.pendingMu.Unlock()

// Cleanup function to remove the pending request
cleanup := func() {
s.pendingMu.Lock()
delete(s.pendingElicitations, id)
s.pendingMu.Unlock()
}
defer cleanup()

// Create the JSON-RPC request
jsonRPCRequest := struct {
JSONRPC string `json:"jsonrpc"`
ID int64 `json:"id"`
Method string `json:"method"`
Params mcp.ElicitationParams `json:"params"`
}{
JSONRPC: mcp.JSONRPC_VERSION,
ID: id,
Method: string(mcp.MethodElicitationCreate),
Params: request.Params,
}

// Marshal and send the request
requestBytes, err := json.Marshal(jsonRPCRequest)
if err != nil {
return nil, fmt.Errorf("failed to marshal elicitation request: %w", err)
}
requestBytes = append(requestBytes, '\n')

if _, err := writer.Write(requestBytes); err != nil {
return nil, fmt.Errorf("failed to write elicitation request: %w", err)
}

// Wait for the response or context cancellation
select {
case <-ctx.Done():
return nil, ctx.Err()
case response := <-responseChan:
if response.err != nil {
return nil, response.err
}
return response.result, nil
}
}
Copy link
Contributor

@coderabbitai coderabbitai bot Aug 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Critical: concurrent writes to stdout are not synchronized in RequestElicitation, risking interleaved/broken JSON

RequestElicitation writes directly to the shared writer without coordinating with the server's write lock. Meanwhile, server responses and notifications use StdioServer.writeResponse with a different lock (s.writeMu). This can interleave bytes across concurrent writes (sampling requests, elicitation requests, server responses, notifications), corrupting frames.

Use a single shared lock for all writes to the underlying stdout writer.

Apply this diff to write under a shared lock:

- if _, err := writer.Write(requestBytes); err != nil {
-   return nil, fmt.Errorf("failed to write elicitation request: %w", err)
- }
+ if _, err := writeWithGlobalLock(writer, requestBytes); err != nil {
+   return nil, fmt.Errorf("failed to write elicitation request: %w", err)
+ }

Additionally, add a shared write helper and mutex (outside the selected range):

// At package level (near other vars)
var stdioWriteMu sync.Mutex

// writeWithGlobalLock ensures all writes to the stdio transport are serialized.
func writeWithGlobalLock(w io.Writer, b []byte) (int, error) {
  stdioWriteMu.Lock()
  defer stdioWriteMu.Unlock()
  return w.Write(b)
}

And update writeResponse and RequestSampling to use the same helper (see separate comments).

🤖 Prompt for AI Agents
In server/stdio.go around lines 239 to 300, RequestElicitation writes directly
to the shared writer without using the global stdio write lock, which can
interleave bytes with other writers; add a package-level mutex stdioWriteMu and
helper writeWithGlobalLock(w io.Writer, b []byte) that locks, writes and
unlocks, then replace the direct writer.Write(requestBytes) call with
writeWithGlobalLock(writer, requestBytes); also ensure writeResponse and
RequestSampling are updated to use the same helper so all stdout writes are
serialized.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

already using s.mu.RLock()

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JBUinfo You're right that s.mu.RLock() is being used, but it only protects access to the writer field itself, not the actual write operation to stdout.

The issue is that once RequestElicitation gets the writer reference (safely with s.mu.RLock()), it then calls writer.Write(requestBytes) without any coordination with other parts of the system that also write to stdout.

Meanwhile, StdioServer.writeResponse uses a separate s.writeMu lock for its writes. This means:

  1. RequestElicitation writes to stdout (unprotected write operation)
  2. writeResponse writes to stdout (protected by s.writeMu)
  3. These can happen concurrently, potentially interleaving bytes and corrupting JSON frames

The s.mu.RLock() protects the field access but not the I/O operation itself. We need all writes to stdout to use the same synchronization mechanism to prevent concurrent writes from corrupting the output stream.

ฅ(•ﻌ•)ฅ


✏️ Learnings added
Learnt from: JBUinfo
PR: mark3labs/mcp-go#548
File: server/stdio.go:239-300
Timestamp: 2025-08-14T16:35:34.070Z
Learning: In server/stdio.go, the s.mu mutex protects access to the writer field, but does not protect the actual write operations to stdout. Concurrent write operations to the same stdout stream need to be synchronized using the same mutex to prevent byte interleaving and JSON corruption.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
server/session.go (1)

55-61: Interface addition is appropriate; clarify cancellation semantics and fix doc grammar.

Good place for the elicitation extension. Tighten the comment to specify context behavior and correct wording.

-// SessionWithElicitation is an extension of ClientSession that can send elicitation requests
+// SessionWithElicitation is an extension of ClientSession that can send elicitation requests.
 type SessionWithElicitation interface {
 	ClientSession
-	// RequestElicitation sends an elicitation request to the client and waits for response
+	// RequestElicitation sends an elicitation request to the client and waits for a response.
+	// Implementations MUST honor ctx cancellation/deadline and return an error consistent with ctx.Err().
 	RequestElicitation(ctx context.Context, request mcp.ElicitationRequest) (*mcp.ElicitationResult, error)
 }

Optionally, add a typed accessor to reduce repetitive type assertions at call sites:

// SessionWithElicitationFromContext retrieves a SessionWithElicitation from context, if available.
func SessionWithElicitationFromContext(ctx context.Context) (SessionWithElicitation, bool) {
	cs := ClientSessionFromContext(ctx)
	swe, ok := cs.(SessionWithElicitation)
	return swe, ok
}
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these settings in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between f149690 and 3573372.

📒 Files selected for processing (1)
  • server/session.go (1 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (1)
server/session.go (1)
mcp/types.go (2)
  • ElicitationRequest (829-832)
  • ElicitationResult (843-850)

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (3)
examples/elicitation/main.go (3)

56-60: Good: guarded assertion prevents panics on malformed elicitation response.

This addresses prior feedback about unsafe map assertions and avoids runtime panics.


121-131: Good: switched to safe parameter access.

Using request.GetString("data", "") avoids unsafe type assertions and is robust when params are missing.


165-168: Don’t proceed on malformed accept payload; fail fast.

Falling back to an empty map will treat malformed responses as declines/missing values. Return an error to make issues explicit.

-				responseData, ok := result.Response.Value.(map[string]interface{})
-				if !ok {
-					responseData = make(map[string]interface{})
-				}
+				responseData, ok := result.Response.Value.(map[string]interface{})
+				if !ok {
+					return nil, fmt.Errorf("unexpected response format")
+				}
🧹 Nitpick comments (4)
examples/elicitation/main.go (4)

22-42: Tighten schema: disallow extraneous fields.

Add additionalProperties: false so clients don’t send unexpected fields.

 				RequestedSchema: map[string]interface{}{
 					"type": "object",
 					"properties": map[string]interface{}{
 						"projectName": map[string]interface{}{
 							"type":        "string",
 							"description": "Name of the project",
 							"minLength":   1,
 						},
 						"framework": map[string]interface{}{
 							"type":        "string",
 							"description": "Frontend framework to use",
 							"enum":        []string{"react", "vue", "angular", "none"},
 						},
 						"includeTests": map[string]interface{}{
 							"type":        "boolean",
 							"description": "Include test setup",
 							"default":     true,
 						},
 					},
+					"additionalProperties": false,
 					"required": []string{"projectName"},
 				},

157-163: Differentiate Decline vs Cancel; return an error on cancellation.

Lumping all non-accept responses into “cancelled” loses semantics and forces clients to parse text. Treat Decline as a non-error result and Cancel as an error.

-				if result.Response.Type != mcp.ElicitationResponseTypeAccept {
-					return &mcp.CallToolResult{
-						Content: []mcp.Content{
-							mcp.NewTextContent("Processing cancelled by user"),
-						},
-					}, nil
-				}
+				switch result.Response.Type {
+				case mcp.ElicitationResponseTypeAccept:
+					// continue to validate fields below
+				case mcp.ElicitationResponseTypeDecline:
+					return &mcp.CallToolResult{
+						Content: []mcp.Content{
+							mcp.NewTextContent("Processing declined by user"),
+						},
+					}, nil
+				case mcp.ElicitationResponseTypeCancel:
+					return nil, fmt.Errorf("processing cancelled by user")
+				default:
+					return nil, fmt.Errorf("unexpected response type: %s", result.Response.Type)
+				}

136-149: Tighten schema for confirmation request.

Disallow extra fields to keep the contract tight.

 						RequestedSchema: map[string]interface{}{
 							"type": "object",
 							"properties": map[string]interface{}{
 								"proceed": map[string]interface{}{
 									"type":        "boolean",
 									"description": "Whether to proceed with processing",
 								},
 								"reason": map[string]interface{}{
 									"type":        "string",
 									"description": "Optional reason for your decision",
 								},
 							},
+							"additionalProperties": false,
 							"required": []string{"proceed"},
 						},

202-204: Handle SIGTERM for graceful shutdown in more environments.

Add syscall.SIGTERM to support container orchestrators.

-	signal.Notify(sigChan, os.Interrupt)
+	signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)

Additional change outside the selected range:

// in the imports block
"syscall"
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these settings in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 8bc9f9c and 46e18dd.

📒 Files selected for processing (2)
  • client/elicitation_test.go (1 hunks)
  • examples/elicitation/main.go (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • client/elicitation_test.go
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-06-20T20:39:51.870Z
Learnt from: lariel-fernandes
PR: mark3labs/mcp-go#428
File: www/docs/pages/servers/prompts.mdx:218-234
Timestamp: 2025-06-20T20:39:51.870Z
Learning: In the mcp-go library, the GetPromptParams.Arguments field is of type map[string]string, not map[string]interface{}, so direct string access without type assertions is safe and correct.

Applied to files:

  • examples/elicitation/main.go
🧬 Code Graph Analysis (1)
examples/elicitation/main.go (5)
server/server.go (4)
  • MCPServer (144-169)
  • ToolHandlerFunc (41-41)
  • NewMCPServer (307-333)
  • WithElicitation (293-297)
mcp/tools.go (2)
  • CallToolRequest (54-58)
  • CallToolResult (40-51)
mcp/types.go (7)
  • ElicitationRequest (829-832)
  • Params (181-181)
  • ElicitationParams (835-840)
  • ElicitationResponseTypeAccept (866-866)
  • Content (941-943)
  • ElicitationResponseTypeDecline (868-868)
  • ElicitationResponseTypeCancel (870-870)
mcp/utils.go (1)
  • NewTextContent (199-204)
server/stdio.go (1)
  • NewStdioServer (325-336)

Comment on lines +170 to +181
if proceed, ok := responseData["proceed"].(bool); !ok || !proceed {
reason := "No reason provided"
if r, ok := responseData["reason"].(string); ok && r != "" {
reason = r
}
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.NewTextContent(fmt.Sprintf("Processing declined: %s", reason)),
},
}, nil
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Validate required field ‘proceed’ on accept path.

Since the schema requires proceed, treat missing/invalid types as an error rather than silently declining.

-				if proceed, ok := responseData["proceed"].(bool); !ok || !proceed {
+				proceed, ok := responseData["proceed"].(bool)
+				if !ok {
+					return nil, fmt.Errorf("invalid or missing 'proceed' in elicitation response")
+				}
+				if !proceed {
 					reason := "No reason provided"
 					if r, ok := responseData["reason"].(string); ok && r != "" {
 						reason = r
 					}
 					return &mcp.CallToolResult{
 						Content: []mcp.Content{
 							mcp.NewTextContent(fmt.Sprintf("Processing declined: %s", reason)),
 						},
 					}, nil
 				}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if proceed, ok := responseData["proceed"].(bool); !ok || !proceed {
reason := "No reason provided"
if r, ok := responseData["reason"].(string); ok && r != "" {
reason = r
}
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.NewTextContent(fmt.Sprintf("Processing declined: %s", reason)),
},
}, nil
}
proceed, ok := responseData["proceed"].(bool)
if !ok {
return nil, fmt.Errorf("invalid or missing 'proceed' in elicitation response")
}
if !proceed {
reason := "No reason provided"
if r, ok := responseData["reason"].(string); ok && r != "" {
reason = r
}
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.NewTextContent(fmt.Sprintf("Processing declined: %s", reason)),
},
}, nil
}
🤖 Prompt for AI Agents
In examples/elicitation/main.go around lines 170 to 180, the code currently
treats a missing or non-boolean "proceed" value as a silent decline; because the
schema requires "proceed", validate that responseData contains a boolean
"proceed" and if it's missing or the type is invalid return an explicit error
(e.g. fmt.Errorf with a clear message) instead of building a CallToolResult
decline; if "proceed" is present and false you may still return the existing
declined CallToolResult, but ensure missing/invalid values short-circuit with an
error response so callers can detect schema violations.

@JBUinfo JBUinfo force-pushed the simistrict-elicitation branch from 46e18dd to 2841d97 Compare August 14, 2025 17:13
@JBUinfo
Copy link
Author

JBUinfo commented Aug 14, 2025

Hi @ezynda3 !
I have resolved the conflicts and applied a couple of "coderabit" changes

@gosthell
Copy link

@JBUinfo I have implemented elicitation support for streamable http transport, maybe this can be added to the pr:
https://github.com/gosthell/mcp-go/tree/pr-548

* Add ElicitationRequest, ElicitationResult, and related types to mcp/types.go
* Implement server-side RequestElicitation method with session support
* Add client-side ElicitationHandler interface and request handling
* Implement elicitation in stdio and in-process transports
* Add comprehensive tests following sampling patterns
* Create elicitation example demonstrating usage patterns
* Use 'Elicitation' prefix for type names to maintain clarity
@JBUinfo JBUinfo force-pushed the simistrict-elicitation branch from 2841d97 to ca542a5 Compare August 20, 2025 11:18
@JBUinfo
Copy link
Author

JBUinfo commented Aug 20, 2025

@ezynda3 i added the @gosthell so now we have HTTP elicitation support

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
client/transport/streamable_http.go (2)

460-468: Closed SSE stream in “ignoreResponse” mode returns “unexpected nil response”

When ignoreResponse is true (continuous listening), the SSE goroutine closes responseChan on EOF, causing the select to receive a nil and return an error. That’s expected termination, not an error. Treat channel close as a clean exit when ignoreResponse is true.

Apply this diff:

-    case response := <-responseChan:
-        if response == nil {
-            return nil, fmt.Errorf("unexpected nil response")
-        }
-        return response, nil
+    case response := <-responseChan:
+        if response == nil {
+            if ignoreResponse {
+                // Stream ended (EOF) — treat as normal termination so caller can reconnect
+                return nil, nil
+            }
+            return nil, fmt.Errorf("unexpected nil response")
+        }
+        return response, nil

261-277: 404 mapped to ErrSessionTerminated breaks initialize’s fallback path

SendRequest tries to “fall through” on ErrSessionTerminated for initialize, but sendHTTP returns nil resp on 404, making status inspection impossible and returning an error anyway.

  • Option A (minimal surface change): Have sendHTTP return the response alongside ErrSessionTerminated so the caller can inspect status and body. Then ensure all callers close resp.Body even on error.
  • Option B: Move 404→ErrSessionTerminated mapping out of sendHTTP into SendRequest (and peers), with initialize handling as a special case.

If you choose A:

-    if resp.StatusCode == http.StatusNotFound {
-        c.sessionID.CompareAndSwap(sessionID, "")
-        return nil, ErrSessionTerminated
+    if resp.StatusCode == http.StatusNotFound {
+        c.sessionID.CompareAndSwap(sessionID, "")
+        return resp, ErrSessionTerminated
     }

Follow-up: Audit callers (SendRequest, SendNotification, sendResponseToServer, createGETConnectionToServer) to close resp.Body when err != nil and resp != nil to avoid leaks. I can prepare a targeted patch set if you prefer.

Also applies to: 393-399

🧹 Nitpick comments (8)
client/transport/streamable_http.go (4)

598-601: Per-attempt connect context no longer times out — consider a bounded attempt duration

Switching to context.WithCancel removes the previous per-attempt timeout. If the server stalls accepting the GET stream, this attempt can hang indefinitely until the parent ctx is canceled. For robustness (especially on flaky networks), keep a bounded attempt duration (e.g., 10s) and retry, while still respecting the parent ctx.

Apply this diff to reintroduce a bounded connect timeout:

-        connectCtx, cancel := context.WithCancel(ctx)
+        connectCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
         err := c.createGETConnectionToServer(connectCtx)
         cancel()

If you’d prefer configurability, introduce an option to override the attempt timeout.


698-701: Hardcoded 30s timeout for server-initiated requests is likely too short for elicitation

Elicitations often require user input and can exceed 30 seconds. A fixed 30s deadline risks spurious cancellations.

Make the timeout configurable (transport option) or inherit a deadline from ctx if set, falling back to a sane default. Example:

-        requestCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
+        deadlineCtx := ctx
+        if _, hasDeadline := ctx.Deadline(); !hasDeadline {
+            // Consider pulling from a configurable option instead of hardcoding
+            deadlineCtx, _ = context.WithTimeout(ctx, 2*time.Minute)
+        }
+        requestCtx, cancel := context.WithCancel(deadlineCtx)

710-716: Differentiate JSON-RPC error codes for cancel vs timeout

Both context.Canceled and context.DeadlineExceeded map to -32800. JSON-RPC 2.0 extensions commonly differentiate:

  • -32800: Request cancelled
  • -32802: Request timeout

Apply this diff:

-            if errors.Is(err, context.Canceled) {
-                errorCode = -32800 // Request cancelled
-                errorMessage = "request was cancelled"
-            } else if errors.Is(err, context.DeadlineExceeded) {
-                errorCode = -32800 // Request timeout
-                errorMessage = "request timed out"
+            if errors.Is(err, context.Canceled) {
+                errorCode = -32800 // Request cancelled
+                errorMessage = "request was cancelled"
+            } else if errors.Is(err, context.DeadlineExceeded) {
+                errorCode = -32802 // Request timeout
+                errorMessage = "request timed out"

473-529: SSE parser only stores the last data: line — consider supporting multi-line SSE data frames

SSE events may include multiple data: lines that must be concatenated with newline. Current logic overwrites data with the last line. This can truncate larger JSON payloads.

Here is a lightweight improvement:

-    var event, data string
+    var event string
+    var dataLines []string
...
-            if line == "" {
+            if line == "" {
                 // Empty line means end of event
-                if data != "" {
+                if len(dataLines) > 0 {
                     // If no event type is specified, use empty string (default event type)
                     if event == "" {
                         event = "message"
                     }
-                    handler(event, data)
-                    event = ""
-                    data = ""
+                    payload := strings.Join(dataLines, "\n")
+                    handler(event, payload)
+                    event = ""
+                    dataLines = nil
                 }
                 continue
             }
...
-            } else if strings.HasPrefix(line, "data:") {
-                data = strings.TrimSpace(strings.TrimPrefix(line, "data:"))
+            } else if strings.HasPrefix(line, "data:") {
+                dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:")))
             }
server/elicitation.go (1)

10-25: Server-side RequestElicitation entry point — LGTM; consider typed errors

Flow and checks are correct. For better programmatic handling, consider defining sentinels:

  • ErrNoActiveSession
  • ErrSessionDoesNotSupportElicitation

and return them (wrapping with fmt.Errorf as needed) instead of stringly errors.

I can add these with tests if you’d like.

server/streamable_http.go (3)

239-247: Unified response handling path — LGTM; update error text to reflect generalized responses

The code/log messages still mention “sampling response”. Consider neutral wording (“request response”) now that elicitation uses the same path.

Apply these string tweaks locally in this handler and handleResponse.


542-609: Generic response handling is solid; adjust sampling-specific wording and tighten error messages

  • Messages like “Missing session ID for sampling response” are now misleading for elicitation.
  • The “sampling response has neither result nor error” case should be generalized.

Apply this diff:

-    http.Error(w, "Missing session ID for sampling response", http.StatusBadRequest)
+    http.Error(w, "Missing session ID for client response", http.StatusBadRequest)
...
-            response.err = fmt.Errorf("sampling error %d: %s", jsonrpcError.Code, jsonrpcError.Message)
+            response.err = fmt.Errorf("client response error %d: %s", jsonrpcError.Code, jsonrpcError.Message)
...
-    response.err = fmt.Errorf("sampling response has neither result nor error")
+    response.err = fmt.Errorf("client response has neither result nor error")

872-915: RequestElicitation mirrors sampling — LGTM; fix copy/paste comment and message text

  • Comment says “Create the sampling request item”.
  • Error messages should not say “sampling” in the elicitation path.

Apply this diff:

-    // Create the sampling request item
+    // Create the elicitation request item
...
-        return nil, fmt.Errorf("elicitation request queue is full - server overloaded")
+        return nil, fmt.Errorf("elicitation request queue is full - server overloaded")

(Also consider updating any remaining “sampling” wording in this block to “elicitation” where applicable.)

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 2841d97 and ca542a5.

📒 Files selected for processing (15)
  • client/client.go (5 hunks)
  • client/elicitation.go (1 hunks)
  • client/elicitation_test.go (1 hunks)
  • client/inprocess_elicitation_test.go (1 hunks)
  • client/transport/inprocess.go (3 hunks)
  • client/transport/streamable_http.go (4 hunks)
  • examples/elicitation/main.go (1 hunks)
  • mcp/types.go (4 hunks)
  • server/elicitation.go (1 hunks)
  • server/elicitation_test.go (1 hunks)
  • server/inprocess_session.go (4 hunks)
  • server/server.go (3 hunks)
  • server/session.go (2 hunks)
  • server/stdio.go (6 hunks)
  • server/streamable_http.go (12 hunks)
🚧 Files skipped from review as they are similar to previous changes (10)
  • client/elicitation.go
  • server/session.go
  • client/elicitation_test.go
  • examples/elicitation/main.go
  • mcp/types.go
  • client/transport/inprocess.go
  • client/inprocess_elicitation_test.go
  • server/inprocess_session.go
  • server/stdio.go
  • server/elicitation_test.go
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-04-06T10:07:06.685Z
Learnt from: leavez
PR: mark3labs/mcp-go#114
File: client/transport/sse.go:137-179
Timestamp: 2025-04-06T10:07:06.685Z
Learning: The SSE client implementation in the MCP-Go project uses a 30-second timeout for reading SSE events to match the behavior of the original implementation before the transport layer refactoring.

Applied to files:

  • client/transport/streamable_http.go
📚 Learning: 2025-06-30T07:13:17.052Z
Learnt from: ezynda3
PR: mark3labs/mcp-go#461
File: server/sampling.go:22-26
Timestamp: 2025-06-30T07:13:17.052Z
Learning: In the mark3labs/mcp-go project, the MCPServer.capabilities field is a struct value (serverCapabilities), not a pointer, so it cannot be nil and doesn't require nil checking. Only pointer fields within the capabilities struct should be checked for nil.

Applied to files:

  • server/server.go
🧬 Code Graph Analysis (4)
server/elicitation.go (3)
server/server.go (1)
  • MCPServer (144-169)
mcp/types.go (2)
  • ElicitationRequest (829-832)
  • ElicitationResult (843-850)
server/session.go (2)
  • ClientSessionFromContext (82-87)
  • SessionWithElicitation (56-60)
server/server.go (1)
mcp/utils.go (1)
  • ToBoolPtr (861-863)
client/client.go (4)
client/elicitation.go (1)
  • ElicitationHandler (11-19)
client/transport/inprocess.go (1)
  • WithElicitationHandler (32-36)
mcp/types.go (12)
  • JSONRPCRequest (329-334)
  • JSONRPCResponse (343-347)
  • MethodSamplingCreateMessage (877-877)
  • MethodElicitationCreate (61-61)
  • MethodPing (25-25)
  • ElicitationParams (835-840)
  • Params (181-181)
  • ElicitationRequest (829-832)
  • Request (172-175)
  • JSONRPC_VERSION (115-115)
  • Result (250-254)
  • NewRequestId (263-265)
client/transport/interface.go (2)
  • JSONRPCRequest (57-62)
  • JSONRPCResponse (64-73)
server/streamable_http.go (3)
mcp/types.go (10)
  • Result (250-254)
  • MethodInitialize (21-21)
  • JSONRPCRequest (329-334)
  • NewRequestId (263-265)
  • Request (172-175)
  • MethodElicitationCreate (61-61)
  • Params (181-181)
  • ElicitationRequest (829-832)
  • CreateMessageResult (904-911)
  • ElicitationResult (843-850)
server/sampling.go (1)
  • SessionWithSampling (42-45)
server/session.go (1)
  • SessionWithElicitation (56-60)
🔇 Additional comments (16)
client/client.go (5)

28-29: Wire-up field for elicitation handler

Addition looks correct and consistent with samplingHandler.


48-55: Client option for elicitation handler — LGTM

Option signature and assignment mirror existing sampling option and integrate cleanly.


179-183: Advertise elicitation capability during initialize — LGTM

Properly gated on handler presence and merged into the outgoing capabilities.


468-476: Incoming request routing updated to include elicitation and ping — LGTM

Dispatch structure mirrors sampling and is easy to extend.


529-575: Transport-level elicitation handling — LGTM

Param parsing, request construction, handler invocation, and response serialization match the sampling pattern.

server/server.go (3)

180-186: Add elicitation capability flag on the server — LGTM

Consistent with existing capability flags and encapsulates opt-in behavior.


292-298: WithElicitation server option — LGTM

Clear, minimal, and consistent with WithLogging/WithToolCapabilities.


639-642: Initialize result advertises elicitation when enabled — LGTM

Appropriately mirrors other capability publications.

server/streamable_http.go (8)

439-454: SSE dispatch for elicitation — LGTM

Correctly mirrors sampling: constructs JSON-RPC request with MethodElicitationCreate and serializes params.


611-643: Delivery to per-request channels — LGTM

Unification via session.requests sync.Map and a single responseItem type improves reuse and keeps the hot path simple. The non-blocking send plus 1-buffered channel is appropriate here.


731-740: responseItem unification — LGTM

json.RawMessage for result maintains transport neutrality across sampling and elicitation.


743-749: Elicitation request envelope — LGTM

Matches sampling’s shape; the separate channel keeps backpressure isolated.


760-766: requests sync.Map and requestIDCounter — LGTM

Converging both flows onto a shared request registry simplifies delivery and teardown.


769-777: Session fields initialization — LGTM

Buffers (10) are reasonable; notification channel size (100) avoids common burst drops.


831-871: RequestSampling uses unified path — LGTM

Good use of per-request channels and explicit delete in defer to avoid leaks. Error messages and unmarshalling errors are clear.


918-919: Interface conformance assertions — LGTM

Good compile-time checks for both sampling and elicitation support.

@JBUinfo JBUinfo force-pushed the simistrict-elicitation branch from 3ba89dd to c56705b Compare August 20, 2025 14:34
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
server/streamable_http.go (2)

469-474: Critical: request ID collision risk between ping and sampling/elicitation

Ping requests currently use s.nextRequestID(sessionID), while sampling/elicitation use session.requestIDCounter. These independent counters will collide (both start at 1), risking misrouting responses (e.g., a ping response being delivered to a sampling channel or vice versa), or spurious 500s.

Unify the ID generator per session by using the same counter for all outgoing requests (sampling, elicitation, heartbeat). Also use method/version constants.

-                    message := mcp.JSONRPCRequest{
-                        JSONRPC: "2.0",
-                        ID:      mcp.NewRequestId(s.nextRequestID(sessionID)),
-                        Request: mcp.Request{
-                            Method: "ping",
-                        },
-                    }
+                    message := mcp.JSONRPCRequest{
+                        JSONRPC: mcp.JSONRPC_VERSION,
+                        ID:      mcp.NewRequestId(session.requestIDCounter.Add(1)),
+                        Request: mcp.Request{
+                            Method: string(mcp.MethodPing),
+                        },
+                    }

Follow-up: with this change, s.sessionRequestIDs and nextRequestID may become unused; consider cleaning them up in a follow-up PR to avoid dead code.


611-642: Don’t 500 on unsolicited responses (e.g., pings); drop gracefully

A client will respond to server heartbeats (ping). Since no pending request channel is registered for pings, deliverResponse returns an error, causing a 500. This is noisy and unnecessary. Drop unsolicited responses when the session exists but there’s no pending waiter.

Also, make log/error messages generic.

-    responseChannelInterface, exists := session.requests.Load(response.requestID)
-    if !exists {
-        return fmt.Errorf("no pending request found for session %s, request %d", sessionID, response.requestID)
-    }
-
-    responseChan, ok := responseChannelInterface.(chan responseItem)
-    if !ok {
-        return fmt.Errorf("invalid response channel type for session %s, request %d", sessionID, response.requestID)
-    }
+    responseChannelInterface, exists := session.requests.Load(response.requestID)
+    if !exists {
+        // Likely a heartbeat ping response or a late/duplicate response. Drop and ACK.
+        s.logger.Debugf("Dropping unsolicited response for session %s, request %d (no pending request)", sessionID, response.requestID)
+        return nil
+    }
+
+    responseChan, ok := responseChannelInterface.(chan responseItem)
+    if !ok {
+        // Unknown placeholder type: drop and ACK to avoid 500s.
+        s.logger.Debugf("Dropping response for session %s, request %d (unexpected channel type)", sessionID, response.requestID)
+        return nil
+    }
@@
-    case responseChan <- response:
-        s.logger.Infof("Delivered sampling response for session %s, request %d", sessionID, response.requestID)
+    case responseChan <- response:
+        s.logger.Infof("Delivered response for session %s, request %d", sessionID, response.requestID)
         return nil
     default:
-        return fmt.Errorf("failed to deliver sampling response for session %s, request %d: channel full or blocked", sessionID, response.requestID)
+        return fmt.Errorf("failed to deliver response for session %s, request %d: channel full or blocked", sessionID, response.requestID)
     }

Note: We still return errors when the session is missing/invalid to preserve existing error semantics (e.g., your “malformed result” test expecting 500).

♻️ Duplicate comments (1)
client/client.go (1)

577-584: Ping handler now returns a proper JSON-RPC success with echoed ID

This addresses the earlier review about wrapping the ID and omitting result; the response now echoes request.ID and includes a non-empty “result”. Nice fix.

Consider handling the unlikely marshal error (even though marshaling an empty struct won’t fail) to be consistent with surrounding code:

-func (c *Client) handlePingRequestTransport(ctx context.Context, request transport.JSONRPCRequest) (*transport.JSONRPCResponse, error) {
-    b, _ := json.Marshal(&mcp.EmptyResult{})
+func (c *Client) handlePingRequestTransport(ctx context.Context, request transport.JSONRPCRequest) (*transport.JSONRPCResponse, error) {
+    b, err := json.Marshal(&mcp.EmptyResult{})
+    if err != nil {
+        return nil, fmt.Errorf("failed to marshal ping result: %w", err)
+    }
     return &transport.JSONRPCResponse{
         JSONRPC: mcp.JSONRPC_VERSION,
         ID:      request.ID,
         Result:  b,
     }, nil
 }
🧹 Nitpick comments (5)
server/streamable_http.go (4)

240-247: Generic response handling: fix sampling-specific log/error messages

You converted response handling to a generic path; the log and HTTP error messages still say “sampling response”. Update to “client response”.

-        if err := s.handleResponse(w, r, jsonMessage); err != nil {
-            s.logger.Errorf("Failed to handle sampling response: %v", err)
-            http.Error(w, "Failed to handle sampling response", http.StatusInternalServerError)
-        }
+        if err := s.handleResponse(w, r, jsonMessage); err != nil {
+            s.logger.Errorf("Failed to handle client response: %v", err)
+            http.Error(w, "Failed to handle client response", http.StatusInternalServerError)
+        }

426-433: Use protocol constant for JSONRPC version

Hardcoding "2.0" is brittle. Prefer mcp.JSONRPC_VERSION for both sampling and elicitation SSE requests.

-                jsonrpcRequest := mcp.JSONRPCRequest{
-                    JSONRPC: "2.0",
+                jsonrpcRequest := mcp.JSONRPCRequest{
+                    JSONRPC: mcp.JSONRPC_VERSION,
                     ID:      mcp.NewRequestId(samplingReq.requestID),
                     Request: mcp.Request{
                         Method: string(mcp.MethodSamplingCreateMessage),
                     },
                     Params: samplingReq.request.CreateMessageParams,
                 }
-                jsonrpcRequest := mcp.JSONRPCRequest{
-                    JSONRPC: "2.0",
+                jsonrpcRequest := mcp.JSONRPCRequest{
+                    JSONRPC: mcp.JSONRPC_VERSION,
                     ID:      mcp.NewRequestId(elicitationReq.requestID),
                     Request: mcp.Request{
                         Method: string(mcp.MethodElicitationCreate),
                     },
                     Params: elicitationReq.request.Params,
                 }

Also applies to: 441-448


542-608: Make handleResponse messages generic (not sampling-specific)

These user-facing and log messages still mention “sampling”. Make them generic to cover elicitation too.

-    http.Error(w, "Missing session ID for sampling response", http.StatusBadRequest)
+    http.Error(w, "Missing session ID for client response", http.StatusBadRequest)
@@
-    http.Error(w, "Invalid request ID in sampling response", http.StatusBadRequest)
+    http.Error(w, "Invalid request ID in client response", http.StatusBadRequest)
@@
-            response.err = fmt.Errorf("sampling error %d: %s", jsonrpcError.Code, jsonrpcError.Message)
+            response.err = fmt.Errorf("client error %d: %s", jsonrpcError.Code, jsonrpcError.Message)
@@
-        response.err = fmt.Errorf("sampling response has neither result nor error")
+        response.err = fmt.Errorf("response has neither result nor error")

124-124: Outdated comment: responses are no longer sampling-only

Update the comment to reflect unified delivery for sampling and elicitation (and ping drops).

-activeSessions    sync.Map // sessionId --> *streamableHttpSession (for sampling responses)
+activeSessions    sync.Map // sessionId --> *streamableHttpSession (for bidirectional responses)
server/streamable_http_sampling_test.go (1)

178-216: Add coverage for heartbeat responses and ID uniqueness

Given the unified response path:

  • Verify that a ping response does not 500 when there’s an active session but no pending request.
  • Verify request IDs don’t collide between heartbeat and sampling/elicitation after unifying the generator.

I can draft a focused unit test that registers an active session, calls deliverResponse with a non-existent request ID, and asserts 200 OK behavior by having handleResponse return nil (i.e., no HTTP write). Alternatively, we can integration-test the ping flow by enabling WithHeartbeatInterval and asserting the POSTed ping response is accepted (200) while a sampling request concurrently succeeds.

Want me to add such tests in a follow-up commit?

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between ca542a5 and c56705b.

📒 Files selected for processing (7)
  • client/client.go (5 hunks)
  • client/elicitation_test.go (1 hunks)
  • client/transport/streamable_http.go (4 hunks)
  • examples/elicitation/main.go (1 hunks)
  • server/session.go (1 hunks)
  • server/streamable_http.go (12 hunks)
  • server/streamable_http_sampling_test.go (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (4)
  • client/transport/streamable_http.go
  • server/session.go
  • examples/elicitation/main.go
  • client/elicitation_test.go
🧰 Additional context used
🧬 Code Graph Analysis (2)
client/client.go (4)
client/elicitation.go (1)
  • ElicitationHandler (11-19)
client/transport/inprocess.go (1)
  • WithElicitationHandler (32-36)
mcp/types.go (12)
  • JSONRPCRequest (329-334)
  • JSONRPCResponse (343-347)
  • MethodSamplingCreateMessage (877-877)
  • MethodElicitationCreate (61-61)
  • MethodPing (25-25)
  • ElicitationParams (835-840)
  • Params (181-181)
  • ElicitationRequest (829-832)
  • Request (172-175)
  • JSONRPC_VERSION (115-115)
  • Result (250-254)
  • EmptyResult (382-382)
client/transport/interface.go (2)
  • JSONRPCRequest (57-62)
  • JSONRPCResponse (64-73)
server/streamable_http.go (3)
mcp/types.go (9)
  • Result (250-254)
  • JSONRPCRequest (329-334)
  • NewRequestId (263-265)
  • Request (172-175)
  • MethodElicitationCreate (61-61)
  • Params (181-181)
  • ElicitationRequest (829-832)
  • CreateMessageResult (904-911)
  • ElicitationResult (843-850)
server/sampling.go (1)
  • SessionWithSampling (42-45)
server/session.go (1)
  • SessionWithElicitation (56-60)
🔇 Additional comments (7)
client/client.go (4)

48-55: Client option wiring for elicitation looks good

Cleanly adds the option and stores the handler; naming and behavior are consistent with Sampling.


179-183: Capability negotiation correctly includes elicitation

Conditionally setting Capabilities.Elicitation when the handler is configured mirrors Sampling and aligns with MCP init. LGTM.


466-476: Server-to-client request dispatch extended correctly

Adding routes for MethodElicitationCreate and MethodPing keeps the transport-level handling symmetrical with sampling. Good separation of concerns.


529-575: Elicitation transport handler is sound

  • Validates handler presence
  • Robustly marshals/unmarshals params/result
  • Returns a compliant JSON-RPC response

This mirrors the sampling path well.

server/streamable_http.go (2)

768-776: Session wiring for unified per-request handling is solid

  • Adds elicitationRequestChan and requests map
  • Initializes channels and keeps buffering small but practical
    This underpins the unified delivery path. LGTM.

872-915: HTTP transport: RequestElicitation implementation looks correct

Mirrors RequestSampling with per-request channels and robust unmarshal. Error messages are specific and helpful.

server/streamable_http_sampling_test.go (1)

188-189: Test updated to new unified response type

Switching to chan responseItem matches the refactor in transport. Test intent remains intact.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Task: Add Elicitation Support
3 participants